爆速で GraphQL サーバーを実装する
予定です。
GraphQL サーバーの実装をしこしこと Node.js で書いていたのですが、
社内向けシステムにそこまで工数割くべきでない
そもそもメンテしづらくなるような負債を残したくない
メンテできるエンジニアが足りない
ということで、できるだけ自動生成するのがよいはずで、イキって DDD っぽく Node.js で GraphQL サーバー実装してやるぞ 💪とか言っている場合ではなかった。
と、以下の記事を読んで思った。
GraphQL 関連で様々な部分を自動化してくれるツールはたくさんある。今回は以下を試してみた。
Prisma
スキーマを定義すると、MySQL や PostgreSQL の DB のマイグレーションまでやってくれる
ただし、既存 DB との統合はできない、マイグレーション先のテーブル構造は Prisma 依存で決定される
GraphQL と MySQL の連携に特化していて、GraphQL クエリから MySQL クエリをうまいこと毎度生成してくれる層
ハイエンドなスペックが出ることは期待しないでほしい、とのこと
Prisma のプロジェクトの一種
GraphQL スキーマから TypeScript のコードを自動生成してくれる
Apollo CLl
GraphQL Relay JS
スキーマ同士を変換する話とか
TL;DR
code-generator + GraphQL Yoga を作るのが良さそうに思える
スキーマファーストで割と自由にディレクトリ構造を決定できるし、変換が単純
graphqlgen は型づけが微妙な部分がぱっと見ある気がする
言語は TypeScript 限定になる
ORM は sequelize-typescript を採用してみる予定
テーブルスキーマからのモデル自動生成には sequelize-typescript-auto を利用する
最終的に実装者がやることは以下
Resolver 実装のテンプレートの配置
Resolver の実装
ORM とか、それを利用するロジックとか。Connection とか Edge の実装とか
Context でやりたいことがあるのならそれの実装
DataLoader とか
graphqlgen
感想とか
やってくれること
GraphQL スキーマ + TypeScript モデル記述 から、リゾルバの定義及びデフォルト実装 を自動生成してくれる
開発者は、追加でリゾルバの実装 だけを行えば良い = ロジックの実装に集中できる
やってくれないこと
ロジックは自分で書く必要がある (逆にロジック以外を書かなくて良い)
Connection や Edge の実装、ORM を利用したデータソースへのアクセス
その他
現状は TypeScript のみを利用
GraphQL Yoga を利用しているので、内部的には Apollo Server
DataLoader を利用したい場合は、Context タイプを指定し、そこに渡せば良さそう
生成された型ファイルの型づけが弱いような...?
Getting started
code:shell
# 初期プロジェクトを生成する
$ mkdir app
$ npm init graphqlgen app
# GraphQL サーバーをスタートする
$ cd app
$ yarn start
# API を叩いてみる
設定項目
graphqlgen.yml に自動生成に関係する各種設定を記述する。
table:設定
設定項目 概要
language 利用する言語 (現在は TypeScript のみ)
schema GraphQL スキーマへのパス
context 全てのリゾルバに共通で渡されるコンテキスト型定義へのパス (別途説明)
models GraphQL に対応する TypeScript モデル
output graphqlgen.ts の出力先
resolver-scaffolding どのように resolver を出力するか (型毎に分割するか、出力先はどこか、等)
用語などなど
context
GraphQL のリゾルバーに共通して渡される context オブジェクト
リゾルバー間で共通して参照したい値を置いておく
DataLoader を利用したい場合は、ここにそのインスタンスを配置したりする
graphqlgen.ts
自動生成される GraphQL のリゾルバーの型定義、及びそのデフォルト実装を含んだファイル
ファイル名は可変だが、一言で言い表しにくかったので
自動生成されたものは基本的に弄らない。ただし、参照はする
更新方法
スキーマに変更を加える場合、以下のような手順で進めていくことになるようだ。
1. src/schema.graphql を変更する
2. src/types.ts を変更する
3. graphqlgen を実行する
4. src/generated/tmp-resolvers から必要なスケルトンを src/resolvers にコピーする
graphqlgen.ts はコピー しない (利用はする)
5. コピーしたファイルの import 分のパスを書き換える
src/generated/graphqlgen.ts へのパスに書き換える
graphql-code-generator
感想とか
やってくれること
テンプレートをカスタマイズできる
やってくれないこと
graphqlgen のように、独立したアプリケーションの雛形を自動生成してくれる類のものではない
その他
swagger-code-gen とか作った人が作っているようだ
data model、query builder、mutaion、filter 等々、様々なものを自動生成可能、とのこと
GraphQL スキーマから REST API を生やすこともできる
バックエンド/フロントエンドを生成可能
テンプレート
ほんの一部だけ
table:templates
テンプレート 概要
graphql-codegen-typescript-template サーバ/クライアントサイドの TypeScript の型定義を生成する
graphql-codegen-typescript-resolvers-template サーバサイドのリゾルバのシグネチャを生成する
Getting Started
code:shell
# インストール
$ npm install --save-dev graphql-code-generator graphql
# テンプレートから自動生成
$ npm install --save-dev <テンプレート名>
$ gql-gen --schema <GraphQLスキーマ> --template <テンプレート名> --out <出力先>
graphql-codegen-typescript-template
TypeScript による GraphQL スキーマの型定義を生成する。
code:shell
$ npm install --save-dev graphql-codegen-typescript-template
$ node_modules/graphql-code-generator/dist/cli.js --schema schema.graphql --template graphql-codegen-typescript-template --out ./typings/
✔ Generated file written to /Users/tasuku_tozawa/workspace/graphql/graphql-code-generator/typings/types.ts
graphql-codegen-typescript-resolvers-template
TypeScript による Resolver の型定義を生成する。
graphqlgen のように、Resolver のデフォルト実装までは用意してくれないので、実装とそれを定義するための雛形のファイルは自身で作成する必要がある。また、grpahqlgen はすべての resolvers をまとめて GraphQL Server に渡せばよいだけの index.ts を生成してくれていたが、こちらは生成してくれない。
code:shell
$ npm install --save-dev graphql-codegen-typescript-resolvers-template
$ node_modules/graphql-code-generator/dist/cli.js --schema schema.graphql --template graphql-codegen-typescript-resolvers-template --out ./resolvers/
✔ Generated file written to /Users/tasuku_tozawa/workspace/graphql/graphql-code-generator/resolvers/resolvers-types.ts
code:typescript
import { QueryResolvers } from './resolvers-types';
import { MyContext } from './context';
export const resolvers: QueryResolvers.Resolvers<MyContext> = {
myQuery: (root, args, context) => {}
};
Apollo CLI
感想とか
ここ でも勘違いしている人が多いのだが、Apollo CLI は GraphQL Client のためのコード自動生成ツールだ スキーマ定義ではなく、クエリ定義を TypeScript 等のコードに変換することで、クエリの実行結果に型を付与して扱うのが目的
スキーマ定義はサーバ側から取得し利用する (schema:download)
Getting Started
code:shell
$ npm install -g apollo
code:shell
# 既存の GraphQL サーバに型定義のメタデータを要求する
✔ Loading Apollo config
✔ Fetching current schema
✔ Saving schema to schema.json
# サーバに対するクエリ定義の SDL を用意しておくと、そのクエリの型が生成される
$ apollo codegen:generate --target=typescript --schema=schema.json --queries="./getUser.graphql"
✔ Loading Apollo config
✔ Scanning for GraphQL queries (1 found)
✔ Generating query files with 'typescript' target - wrote 2 files
たまにエラーになるときはクエリが間違っている可能性が高い。yarn 等でローカルに DL し下記 issue を参考にライブラリの js コードを書き換えると原因がわかったりする。
サンプル
簡単なクライアントコードを用意してみることにする。
code:shell
$ mkdir sample
$ cd sample
$ yarn add apollo-client apollo-cache-inmemory graphql graphql-tag node-fetch ts-node typescript
$ yarn add -D apollo
# スキーマ定義をサーバから DL
$ ls
$ ls
node_modules package.json schema.json yarn.lock
# クエリを準備する
$ mkdir query
$ vim query/getCollection.ts
code:typescript
import gql from "graphql-tag";
export const query = gql`
query getCollection {
collection(id: 1) {
id
modified_user
}
}
`
code:shell
$ yarn apollo codegen:generate --target=typescript --schema=schema.json --tagName=gql --queries="./query/*.ts"
yarn run v1.12.1
✔ Loading Apollo config
✔ Scanning for GraphQL queries (1 found)
✔ Generating query files with 'typescript' target - wrote 1 files
✨ Done in 1.66s.
$ tree -I node_modules
.
├── __generated__
│ └── globalTypes.ts
├── package.json
├── query
│ ├── __generated__
│ │ └── getCollection.ts
│ └── getCollection.ts
├── schema.json
└── yarn.lock
3 directories, 6 files
下記のような index.ts を書く。
code:typescript
import { ApolloClient } from 'apollo-client';
import { HttpLink } from 'apollo-link-http';
import { InMemoryCache } from 'apollo-cache-inmemory';
import fetch from 'node-fetch';
const client = new ApolloClient({
ssrMode: true,
link: new HttpLink({
fetch,
}),
cache: new InMemoryCache(),
});
// ここはわざわざ事前定義していたクエリから引っ張って来ずとも良い
// ここで独自に gql でクエリを定義し、戻り値に getCollection 型をあてはめるだけでも型の恩恵を得られる
import { query } from './query/getCollection';
import { getCollection } from './query/__generated__/getCollection';
client
.query({
query
})
.then(result => {
// 戻り値に型をつけることができる
const data: getCollection = result.data as getCollection;
console.log(data);
});
実行する。
code:shell
$ node_modules/.bin/ts-node index.ts
{ collection: { modified_user: 'tasuwo', __typename: 'Collection' } }
型の恩恵を受けることができた。主に、React apollo 等で UI コンポーネントを生成するときに型があると役立つ。
Tips
Cannot query field "collection" on type "Query" といったエラーが出た際は、graphql ライブラリがコンフリクトしている可能性がある。
code:shell
GraphQLError: Cannot query field "collection" on type "Query"
at Compiler.compileSelection (~/workspace/graphql/apollo-cli2/node_modules/apollo-codegen-core/lib/compiler/index.js:120:27)
at selections.selectionSetNode.selections.map.selectionNode (~/workspace/graphql/apollo-cli2/node_modules/apollo-codegen-core/lib/compiler/index.js:106:76)
at Array.map (<anonymous>)
at Compiler.compileSelectionSet (~/workspace/graphql/apollo-cli2/node_modules/apollo-codegen-core/lib/compiler/index.js:106:18)
at Compiler.compileOperation (~/workspace/graphql/apollo-cli2/node_modules/apollo-codegen-core/lib/compiler/index.js:86:32)
at Object.compileToIR (~/workspace/graphql/apollo-cli2/node_modules/apollo-codegen-core/lib/compiler/index.js:15:44)
at Object.generate as default (~/workspace/graphql/apollo-cli2/node_modules/apollo/lib/generate.js:74:36) at Task.task (~/workspace/graphql/apollo-cli2/node_modules/apollo/lib/commands/codegen/generate.js:98:64)
at Promise.resolve.then.then.skipped (~/workspace/graphql/apollo-cli2/node_modules/listr/lib/task.js:167:30)
at <anonymous>
error Command failed with exit code 1.
code:shell
$ yarn remove graphql
削除したら治った
参考
図がわかりやすい。もとは apollo-codegen というツール名だったようだ
GraphQL Relay JS
感想とか
やってくれること
基本的には、GraphQL.js でスキーマ定義をする時に、Relay で用いるパターンを容易に定義するための関数が用意されているっぽい
例えば、connectionDefinitions によって connection を定義できる
GraphQL のカーソルベースのページネーションの際の Connection, Edge 等の概念は元は Relay Specification のものなので、通常の型からそれらの定義を生成できる API 等が用意されているということになる
やってくれないこと
GraphQL の SDL から Connection や Edge を生成、といった様な、SDL <-> GraphQL.js オブジェクト間の変換はしない
JS コード上で Connection や Edge を通常の型から定義できる
その他
Connection や Edge をスキーマとして定義するのは正直面倒なので、このライブラリを利用して簡略化できるならしたい気持ちはある
Join Monster
感想とか
やってくれること
GraphQL クエリから SQL クエリへの変換
GraphQL Relay ライブラリと併用できる
Connection や Edge を GraphQL Relay ライブラリを通して定義し、利用できる
ページネーションのロジックを自前で実装しなくて良い
アプリケーションレイヤー、オフセット、カーソルでのページネーションに各々対応している
やってくれないこと
GraphQL.js によるスキーマ定義にメタデータを追加していく形式なので、SDL で記述した GraphQL スキーマを変換、といったことはやってくれない
変換後の SQL クエリの実行
サンプルでは knex を ORM として利用している
わたされた SQL クエリをそのまま実行できさえすればいいだけではある
ユーザ入力のフィールド値のエスケープ
何も考えないと SQL インジェクション等の脆弱性対策ができない
GraphQL サーバーを立てるには別のライブラリを利用する必要がある
Apollo サーバーに GraphQL.js スキーマを渡すだけでいけそうではある
その他
GraphQLObjectType と SQL テーブルのマッピング (Query Planning) を行う
カラムと一対一対応させる必要はない
通常の定義に対してメタデータを追加する
開発者がやること
graphql-js を利用して GraphQL スキーマを定義する
GraphQL スキーマに、各フィールドと SQL のテーブル/カラムとのマッピングをメタデータとして追加
複数の SQL カラムと GraphQL の 1 つのフィールドをマッピング (Computed column) することも可能
GraphQL スキーマに、クエリの where/join 条件等をメタデータとして追加
最終的に JoinMonster によって生成されたSQL クエリを実行する (knex や Sequelize 等の ORM を利用すれば良い)
バックに自前の SQL データベースを利用しているならかなり工数の削減ができそう
特に、小規模なプロジェクトなら一瞬でパフォーマンスがそこそこ出る GraphQL サーバが立てられそう
ただし、スキーマ定義とresolverのロジックを一緒くたに記述する必要があるので、大規模になるにつれてコードベースが複雑化しそうではある
逆に、うまく責務を分離して書くことができればかなり良さそうに思える
ただ、公式でメンテされているものではない
フィルタリングやページネーションの方法が気になる。そこの実装が割と面倒な様な気がする
Getting Started
code:shell
npm install graphql join-monster
code:javascript
import { GraphQLSchema } from 'graphql'
import { GraphQLObjectType, GraphQLList, GraphQLString, GraphQLInt } from 'graphql'
const User = new GraphQLObjectType({
name: 'User',
// SQL テーブルの情報を追加
// テーブルを示す SQL クエリでも良い (VIEW や derived table のように振る舞う)
slqTable: 'accounts',
// ユニークキーの設定
// 実際のデータベースが unique 制約を持っている必要はない
// unique の制約を保つことは join-monster は担保しない
// 複合キーを指定したい場合は、配列で指定する
uniqueKey: 'id',
fields: () => ({
id: {
type: GraphQLInt
// カラム名とフィールド名が一致する場合は、sqlColumn は指定の必要がない
},
email: {
type: GraphQLString,
// カラム名とフィールド名が一致しない場合は、明示的に指定する
sqlColumn: 'email_address'
},
idEncoded: {
description: 'The ID base-64 encoded',
type: GraphQLString,
// 値に resolver を適用する場合、sqlColumn は必ず指定する必要がある
// 全てのスキーマに resolver を追加するような Optics や graphql-tools といったライブラリ
// を利用する場合にも、毎回指定する必要がある
sqlColumn: 'id',
resolve: user => toBase64(user.idEncoded)
},
fullName: {
description: 'A user\'s first and last name',
type: GraphQLString,
// 複数の SQL カラムを組み合わせた Compulted Column を定義できる
// 複数の SQL カラムを配列で渡すと、それらが resolver に expose される
resolve: user => ${user.first_name} ${user.last_name}
// sqlDeps と resolve を組み合わせなくとも、SQL をベタがきすることもできる
// その場合は sqlExpr を利用する
// table のエイリアスが joinMonster によって自動生成され、渡される
// sqlExpr: table => ${table}.first_name || ' ' || ${table}.last_name
}
})
})
import joinMonster from 'join-monster'
const QueryRoot = new GraphQLObjectType({
name: 'Query',
fields: () => ({
users: {
type: new GraphQLList(User),
resolve: (parent, args, context, resolveInfo) => {
// resolveInfo はスキーマ定義とパースされたクエリが含まれるので、それを第一引数として渡す
// 第二引数には context、第三引数には SQL クエリを引数に取り、 "データを返す Promise" を返すコールバック関数を渡す
return joinMonster(resolveInfo, {}, sql => {
return knex.raw(sql)
})
}
},
user: {
type: User,
args: {
id: { type: new GraphQLNonNull(GraphQLInt) }
},
// Where 条件を追加するには、以下を引数に取る関数を where フィールドに定義する
//
// usersTable: JoinMonster によって自動生成されるテーブルエイリアス
// args: args カラムで指定された値群
// context; コンテキストオブジェクト
//
// 注意: ここでの返り値がそのまま SQL に埋め込まれるので、ユーザの入力はエスケープすることを忘れないこと
// SQL インジェクション等の脆弱性を生む可能性があるので
where: (usersTable, args, context) => {
return ${usersTable}.id = ${args.id}
},
resolve: (parent, args, context, resolveInfo) => {
return joinMonster(resolveInfo, {}, sql => {
return knex.raw(sql)
})
}
}
}
})
})
export default new GraphQLSchema({
description: 'a test schema',
query: QueryRoot
})